Micron Document
πŸŽ–οΈGitΠ―Ρ€Π°πŸŽ–οΈ

Commit 65a1f5ce4d0e4b8eafcd0bbfccafd41dc6ee888a


Parents : 7bc6722
Author : James Rich <2199651+jamesarich@users.noreply.github.com>
Signature : Signature validation error
Date : 2026-08-17T16:10:57Z
Committer : GitHub <noreply@github.com>
Date : 2026-08-17T16:10:57Z

fix(takserver): route TAK self-test through the real v1/v2 dispatch path (#6746)

Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>

Changes
Diff

diff --git a/.skills/compose-ui/strings-index.txt b/.skills/compose-ui/strings-index.txt
index a98bb3a978..86d5532a05 100644
--- a/.skills/compose-ui/strings-index.txt
+++ b/.skills/compose-ui/strings-index.txt
@@ -1699,9 +1699,12 @@ tak_server_status_unavailable
tak_server_status_waiting
tak_server_test_card_title
tak_server_test_idle
+tak_server_test_protocol_v1_label
+tak_server_test_protocol_v2_label
tak_server_test_result_bytes
+tak_server_test_result_expected_drop
tak_server_test_result_unknown_error
-tak_server_test_results
+tak_server_test_results_v2
tak_server_test_run
tak_server_test_running
tak_team

diff --git a/core/resources/src/commonMain/composeResources/values/strings.xml b/core/resources/src/commonMain/composeResources/values/strings.xml
index c930730dfc..e7d8ba8895 100644
--- a/core/resources/src/commonMain/composeResources/values/strings.xml
+++ b/core/resources/src/commonMain/composeResources/values/strings.xml
@@ -1747,9 +1747,12 @@
<string name="tak_server_status_waiting">Listening on 127.0.0.1:8089 β€” waiting for ATAK</string>
<string name="tak_server_test_card_title">TAK Mesh Test (Debug)</string>
<string name="tak_server_test_idle">Send all %1$d test fixtures to mesh</string>
+ <string name="tak_server_test_protocol_v1_label">V1 (legacy firmware &lt; 2.8.0 β€” PLI + chat only)</string>
+ <string name="tak_server_test_protocol_v2_label">V2 (firmware β‰₯ 2.8.0)</string>
<string name="tak_server_test_result_bytes">%1$dB βœ“</string>
+ <string name="tak_server_test_result_expected_drop">Not in v1 schema (expected)</string>
<string name="tak_server_test_result_unknown_error">βœ—</string>
- <string name="tak_server_test_results">%1$d passed, %2$d failed of %3$d/%4$d</string>
+ <string name="tak_server_test_results_v2">%1$d passed, %2$d expected drop, %3$d failed of %4$d/%5$d</string>
<string name="tak_server_test_run">Run</string>
<string name="tak_server_test_running">Running: %1$s</string>
<string name="tak_team">Team Color</string>

diff --git a/core/takserver/src/commonMain/kotlin/org/meshtastic/core/takserver/TAKMeshIntegration.kt b/core/takserver/src/commonMain/kotlin/org/meshtastic/core/takserver/TAKMeshIntegration.kt
index 4fa13911be..c7f6220fb0 100644
--- a/core/takserver/src/commonMain/kotlin/org/meshtastic/core/takserver/TAKMeshIntegration.kt
+++ b/core/takserver/src/commonMain/kotlin/org/meshtastic/core/takserver/TAKMeshIntegration.kt
@@ -47,6 +47,30 @@ import kotlin.random.Random
import kotlin.time.Clock
import kotlin.time.Duration.Companion.minutes
+/**
+ * Outcome of a single outbound CoT dispatch attempt via [TAKMeshIntegration.sendCoTToMeshV1] /
+ * [TAKMeshIntegration.sendCoTToMeshV2]. Exposed (internal) so [TakMeshTestRunner] can distinguish an intentional,
+ * schema-driven drop (e.g. v1's TAKPacket only representing PLI/GeoChat) from a genuine send failure, rather than
+ * reporting both as the same opaque "failed".
+ */
+internal sealed interface TakSendOutcome {
+ /** The CoT was successfully handed to [CommandSender.sendData] as [wireBytes] bytes. */
+ data class Sent(val wireBytes: Int) : TakSendOutcome
+
+ /**
+ * The CoT was never sent because the active protocol's schema/size limits can't represent it.
+ *
+ * @param schemaLimited true only when the drop is because the protocol's CoT type coverage doesn't include this
+ * fixture's type (e.g. v1's TAKPacket only representing PLI/GeoChat) β€” a known, permanent limitation of that
+ * protocol version. False for an oversize drop or any other reason: those are real MTU/size problems that happen
+ * to hit a payload that *is* representable, and must not be reported as an expected/intentional limitation.
+ */
+ data class Dropped(val reason: String, val schemaLimited: Boolean) : TakSendOutcome
+
+ /** The CoT should have been sent but the attempt threw (radio disconnected, queue full, etc). */
+ data class Failed(val reason: String) : TakSendOutcome
+}
+
/**
* Bidirectional bridge between the local TAK server and the Meshtastic mesh network.
*
@@ -64,6 +88,7 @@ import kotlin.time.Duration.Companion.minutes
* from older nodes in mixed-firmware mesh deployments.
*/
@OptIn(ExperimentalAtomicApi::class)
+@Suppress("TooManyFunctions")
class TAKMeshIntegration(
private val takServerManager: TAKServerManager,
private val commandSender: CommandSender,
@@ -173,20 +198,29 @@ class TAKMeshIntegration(
return Capabilities(fw).supportsTakV2
}
- private suspend fun sendCoTToMesh(cotMessage: CoTMessage) {
- if (useTakV2()) {
- sendCoTToMeshV2(cotMessage)
- } else {
- sendCoTToMeshV1(cotMessage)
- }
+ private suspend fun sendCoTToMesh(cotMessage: CoTMessage): TakSendOutcome = if (useTakV2()) {
+ sendCoTToMeshV2(cotMessage)
+ } else {
+ sendCoTToMeshV1(cotMessage)
}
+ /**
+ * Test-only entry point for [TakMeshTestRunner]'s debug self-test: dispatch [cotMessage] through the real
+ * production v1/v2 pipeline, but with the protocol explicitly forced via [forceV2] instead of read from the
+ * connected radio's firmware (as [useTakV2] does). This lets the self-test exercise BOTH real dispatch paths
+ * deterministically in a single run regardless of which firmware happens to be connected β€” closing the blind spot
+ * where the self-test always exercised the v2 pipeline even when a real connected radio on firmware < 2.8.0 would
+ * silently fall back to the much more limited v1 path.
+ */
+ internal suspend fun sendCoTToMeshForTest(cotMessage: CoTMessage, forceV2: Boolean): TakSendOutcome =
+ if (forceV2) sendCoTToMeshV2(cotMessage) else sendCoTToMeshV1(cotMessage)
+
/**
* v2 send path (firmware >= 2.8.0): SDK parser + zstd dictionary compression, full typed payload support
* (DrawnShape, Marker, Route, Aircraft, Casevac, Emergency, Task, plus PLI / GeoChat). Wire format: `[flags
* byte][zstd-compressed TAKPacketV2 protobuf]` on port 78 (ATAK_PLUGIN_V2).
*/
- private suspend fun sendCoTToMeshV2(cotMessage: CoTMessage) {
+ private suspend fun sendCoTToMeshV2(cotMessage: CoTMessage): TakSendOutcome {
// Prefer the sourceEventXml for shape/marker/route types β€” the SDK's
// CotXmlParser extracts compact typed payloads (DrawnShape, Marker,
// Route, etc.) that compress far better than raw_detail encoding.
@@ -236,14 +270,20 @@ class TAKMeshIntegration(
}
}
}
- return
+ return TakSendOutcome.Dropped(
+ "Oversized (>${MAX_TAK_WIRE_PAYLOAD_BYTES}B)",
+ schemaLimited = false,
+ )
}
} catch (e: Exception) {
Logger.w(e) { "SDK parser/compressor failed for ${cotMessage.type}, trying app conversion" }
val takPacketV2 = cotMessage.toTAKPacketV2()
if (takPacketV2 == null) {
Logger.w { "Cannot convert CoT type ${cotMessage.type} to TAKPacketV2, dropping" }
- return
+ return TakSendOutcome.Dropped(
+ "Cannot convert CoT type ${cotMessage.type} to TAKPacketV2",
+ schemaLimited = false,
+ )
}
try {
TakV2Compressor.compress(takPacketV2)
@@ -253,7 +293,7 @@ class TAKMeshIntegration(
}
}
- try {
+ return try {
val dataPacket =
DataPacket(
to = NodeAddress.ID_BROADCAST,
@@ -262,6 +302,7 @@ class TAKMeshIntegration(
)
commandSender.sendData(dataPacket)
Logger.d { "Sent V2 to mesh: ${cotMessage.type} (${wirePayload.size} bytes)" }
+ TakSendOutcome.Sent(wirePayload.size)
} catch (e: kotlin.coroutines.cancellation.CancellationException) {
throw e
} catch (e: Exception) {
@@ -269,6 +310,7 @@ class TAKMeshIntegration(
Logger.e(e) {
"Failed to send TAKPacketV2 to mesh (${cotMessage.type}, ${wirePayload.size} bytes): ${e.message}"
}
+ TakSendOutcome.Failed(e.message ?: "Send failed")
}
}
@@ -277,7 +319,7 @@ class TAKMeshIntegration(
* compression. Only PLI and GeoChat payloads are supported by the v1 schema β€” shapes, markers, routes, casevac,
* emergency, and task CoT events are dropped with a warning.
*/
- private suspend fun sendCoTToMeshV1(cotMessage: CoTMessage) {
+ private suspend fun sendCoTToMeshV1(cotMessage: CoTMessage): TakSendOutcome {
val takPacket =
cotMessage.toTAKPacket()
?: run {
@@ -286,7 +328,10 @@ class TAKMeshIntegration(
"in v1 TAKPacket schema (only PLI and GeoChat are supported). " +
"Upgrade radio firmware to >= 2.8.0 for full payload support."
}
- return
+ return TakSendOutcome.Dropped(
+ "Not representable in v1 TAKPacket schema (only PLI and GeoChat are supported)",
+ schemaLimited = true,
+ )
}
val wirePayload = TAKPacket.ADAPTER.encode(takPacket)
@@ -295,10 +340,10 @@ class TAKMeshIntegration(
"Dropping oversized v1 TAK packet: type=${cotMessage.type} " +
"size=${wirePayload.size}B max=$MAX_TAK_WIRE_PAYLOAD_BYTES"
}
- return
+ return TakSendOutcome.Dropped("Oversized (>${MAX_TAK_WIRE_PAYLOAD_BYTES}B)", schemaLimited = false)
}
- try {
+ return try {
val dataPacket =
DataPacket(
to = NodeAddress.ID_BROADCAST,
@@ -307,12 +352,14 @@ class TAKMeshIntegration(
)
commandSender.sendData(dataPacket)
Logger.d { "Sent V1 to mesh: ${cotMessage.type} (${wirePayload.size} bytes)" }
+ TakSendOutcome.Sent(wirePayload.size)
} catch (e: kotlin.coroutines.cancellation.CancellationException) {
throw e
} catch (e: Exception) {
Logger.e(e) {
"Failed to send v1 TAKPacket to mesh (${cotMessage.type}, ${wirePayload.size} bytes): ${e.message}"
}
+ TakSendOutcome.Failed(e.message ?: "Send failed")
}
}

diff --git a/core/takserver/src/commonMain/kotlin/org/meshtastic/core/takserver/TakMeshTestRunner.kt b/core/takserver/src/commonMain/kotlin/org/meshtastic/core/takserver/TakMeshTestRunner.kt
index 01752df81b..a53571db89 100644
--- a/core/takserver/src/commonMain/kotlin/org/meshtastic/core/takserver/TakMeshTestRunner.kt
+++ b/core/takserver/src/commonMain/kotlin/org/meshtastic/core/takserver/TakMeshTestRunner.kt
@@ -24,11 +24,15 @@ import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.sync.Mutex
-import okio.ByteString.Companion.toByteString
-import org.meshtastic.core.model.DataPacket
-import org.meshtastic.core.model.NodeAddress
-import org.meshtastic.core.repository.CommandSender
-import org.meshtastic.proto.PortNum
+
+/** Which outbound TAK protocol a [TakTestResult] was dispatched through. See [TAKMeshIntegration]. */
+enum class TakProtocol {
+ /** Firmware >= 2.8.0: zstd-compressed TAKPacketV2 on port 78, full typed CoT support. */
+ V2,
+
+ /** Firmware <= 2.7.x: bare-protobuf legacy TAKPacket on port 72, PLI + GeoChat only. */
+ V1,
+}
/** Result of sending a single test fixture through the TAK mesh pipeline. */
data class TakTestResult(
@@ -37,15 +41,26 @@ data class TakTestResult(
val compressedBytes: Int,
val passed: Boolean,
val error: String? = null,
+ val protocol: TakProtocol = TakProtocol.V2,
+ // Mirrors TakSendOutcome.Dropped.schemaLimited: true only when this fixture was dropped because the active
+ // protocol's CoT type coverage doesn't include it (e.g. v1's TAKPacket only representing PLI/GeoChat) β€” a
+ // known, permanent limitation of that protocol, so this is expected/intentional behavior, not a self-test
+ // regression. False for an oversize drop (a real MTU problem, even if it happened on v1) or any failure β€”
+ // never set purely because the result's protocol is V1.
+ val expectedDrop: Boolean = false,
)
/**
- * Debug-only test runner that sends the SDK's CoT XML test fixtures through the real TAK mesh pipeline: strip β†’ parse β†’
- * compress β†’ send to mesh radio.
+ * Debug-only test runner that sends the SDK's CoT XML test fixtures through the REAL TAK mesh pipeline: strip β†’ parse β†’
+ * dispatch, via [TAKMeshIntegration.sendCoTToMeshForTest]. Each fixture is run through BOTH the v2 (firmware >= 2.8.0)
+ * and v1 (legacy) dispatch paths so the self-test's pass/fail rate reflects what a real connected radio would actually
+ * do on either firmware generation, rather than always exercising the v2 pipeline regardless of the connected radio's
+ * firmware β€” see [TAKMeshIntegration.useTakV2].
*
- * Paces sends by waiting [sendDelayMs] between each fixture to avoid flooding the radio's TX queue.
+ * Paces sends by waiting [sendDelayMs] between each successfully-sent fixture to avoid flooding the radio's TX queue.
+ * Fixtures that are dropped (not sent) incur no delay.
*/
-class TakMeshTestRunner(private val commandSender: CommandSender) {
+class TakMeshTestRunner(private val takMeshIntegration: TAKMeshIntegration) {
private val _results = MutableStateFlow<List<TakTestResult>>(emptyList())
val results: StateFlow<List<TakTestResult>> = _results.asStateFlow()
@@ -118,8 +133,8 @@ class TakMeshTestRunner(private val commandSender: CommandSender) {
}
/**
- * Run all test fixtures sequentially, sending each through the mesh pipeline. Updates [results] and
- * [currentFixture] as each fixture is processed.
+ * Run all test fixtures sequentially through both the v2 and v1 dispatch paths. Updates [results] and
+ * [currentFixture] as each fixture is processed. [results] accumulates v2 runs first, then v1 runs.
*/
suspend fun runAll() {
// Use tryLock to prevent concurrent test runs: if another coroutine is already
@@ -133,74 +148,95 @@ class TakMeshTestRunner(private val commandSender: CommandSender) {
val allResults = mutableListOf<TakTestResult>()
- for (name in FIXTURE_NAMES) {
- _currentFixture.value = name
- val result = runSingleFixture(name)
- allResults.add(result)
- _results.value = allResults.toList()
-
- if (result.passed) {
- // Wait for radio airtime + ACK before next send
- delay(SEND_DELAY_MS)
+ for (protocol in listOf(TakProtocol.V2, TakProtocol.V1)) {
+ for (name in FIXTURE_NAMES) {
+ _currentFixture.value = "$name (${protocol.name.lowercase()})"
+ val result = runSingleFixture(name, protocol)
+ allResults.add(result)
+ _results.value = allResults.toList()
+
+ if (result.passed) {
+ // Wait for radio airtime + ACK before next send. Dropped fixtures never
+ // reached the radio, so there's nothing to pace.
+ delay(SEND_DELAY_MS)
+ }
}
}
_currentFixture.value = null
- val passed = allResults.count { it.passed }
- val failed = allResults.size - passed
- Logger.i { "TAK Mesh Test complete: $passed/${allResults.size} passed, $failed failed" }
+ val v2 = allResults.filter { it.protocol == TakProtocol.V2 }
+ val v1 = allResults.filter { it.protocol == TakProtocol.V1 }
+ val v2Passed = v2.count { it.passed }
+ val v1Passed = v1.count { it.passed }
+ val v1ExpectedDrops = v1.count { it.expectedDrop }
+ Logger.i {
+ "TAK Mesh Test complete: v2=$v2Passed/${v2.size} passed; " +
+ "v1=$v1Passed/${v1.size} passed ($v1ExpectedDrops expected drops β€” " +
+ "v1's legacy schema only supports PLI/GeoChat)"
+ }
} finally {
_isRunning.value = false
runMutex.unlock()
}
}
- private suspend fun runSingleFixture(name: String): TakTestResult {
+ private suspend fun runSingleFixture(name: String, protocol: TakProtocol): TakTestResult {
+ val forceV2 = protocol == TakProtocol.V2
+
// Load fixture XML from bundled resources via platform-specific loader
val xml =
try {
loadTakFixtureXml(name)
} catch (e: Exception) {
Logger.w(e) { "Failed to load fixture $name" }
- return TakTestResult(name, 0, 0, false, "Load failed: ${e.message}")
+ return TakTestResult(name, 0, 0, false, "Load failed: ${e.message}", protocol)
}
- // Apply the same pipeline as TAKMeshIntegration.sendCoTToMesh()
- val freshXml = TAKMeshIntegration.ensureMinimumStaleForMesh(xml)
- val strippedXml = TAKMeshIntegration.stripNonEssentialElements(freshXml)
-
- // Parse and compress via SDK
- val wirePayload: ByteArray
- try {
- val result = TakSdkCompressor.compressCoT(strippedXml, MAX_TAK_WIRE_PAYLOAD_BYTES)
- val compressed = result.wirePayload
- if (compressed == null) {
- Logger.w { "TAK Test: $name oversized even without remarks (xml=${xml.length}B)" }
- return TakTestResult(name, xml.length, 0, false, "Oversized (>${MAX_TAK_WIRE_PAYLOAD_BYTES}B)")
+ // Parse into the same CoTMessage shape a real inbound TAK-client message would produce
+ // (sourceEventXml preserved) β€” see CoTXmlParser.buildCoTMessage.
+ val cotMessage =
+ CoTXmlParser(xml).parse().getOrElse { e ->
+ Logger.w(e) { "TAK Test: $name failed to parse as CoT XML: ${e.message}" }
+ return TakTestResult(name, xml.length, 0, false, "Parse failed: ${e.message}", protocol)
}
- wirePayload = compressed
- } catch (e: Exception) {
- Logger.w(e) { "TAK Test: $name compression failed: ${e.message}" }
- return TakTestResult(name, xml.length, 0, false, "Compress failed: ${e.message}")
- }
- // Send to mesh
- try {
- val dataPacket =
- DataPacket(
- to = NodeAddress.ID_BROADCAST,
- bytes = wirePayload.toByteString(),
- dataType = PortNum.ATAK_PLUGIN_V2.value,
- )
- commandSender.sendData(dataPacket)
- Logger.i { "TAK Test: $name β†’ ${wirePayload.size}B (xml=${xml.length}B)" }
- return TakTestResult(name, xml.length, wirePayload.size, true)
+ // Dispatch through the REAL production pipeline (TAKMeshIntegration.sendCoTToMeshV1/V2),
+ // with the protocol forced rather than read from the connected radio's firmware.
+ return try {
+ when (val outcome = takMeshIntegration.sendCoTToMeshForTest(cotMessage, forceV2)) {
+ is TakSendOutcome.Sent -> {
+ Logger.i { "TAK Test: $name β†’ ${outcome.wireBytes}B (xml=${xml.length}B) via $protocol" }
+ TakTestResult(name, xml.length, outcome.wireBytes, true, protocol = protocol)
+ }
+
+ is TakSendOutcome.Dropped -> {
+ Logger.w { "TAK Test: $name dropped on $protocol: ${outcome.reason}" }
+ TakTestResult(
+ fixtureName = name,
+ xmlBytes = xml.length,
+ compressedBytes = 0,
+ passed = false,
+ error = outcome.reason,
+ protocol = protocol,
+ // Only a schema-coverage drop is an expected/intentional limitation (v1's legacy
+ // schema legitimately supports only PLI/GeoChat). An oversize drop is a real MTU
+ // problem regardless of which protocol hit it, and must never be mislabeled as
+ // expected just because it happened on v1.
+ expectedDrop = outcome.schemaLimited,
+ )
+ }
+
+ is TakSendOutcome.Failed -> {
+ Logger.w { "TAK Test: $name send failed on $protocol: ${outcome.reason}" }
+ TakTestResult(name, xml.length, 0, false, outcome.reason, protocol)
+ }
+ }
} catch (e: kotlin.coroutines.cancellation.CancellationException) {
throw e
} catch (e: Exception) {
Logger.w(e) { "TAK Test: $name send failed: ${e.message}" }
- return TakTestResult(name, xml.length, wirePayload.size, false, "Send failed: ${e.message}")
+ TakTestResult(name, xml.length, 0, false, "Send failed: ${e.message}", protocol)
}
}
}

diff --git a/core/takserver/src/commonTest/kotlin/org/meshtastic/core/takserver/TAKMeshIntegrationTest.kt b/core/takserver/src/commonTest/kotlin/org/meshtastic/core/takserver/TAKMeshIntegrationTest.kt
index 003ed61e8d..f579b9d101 100644
--- a/core/takserver/src/commonTest/kotlin/org/meshtastic/core/takserver/TAKMeshIntegrationTest.kt
+++ b/core/takserver/src/commonTest/kotlin/org/meshtastic/core/takserver/TAKMeshIntegrationTest.kt
@@ -48,6 +48,7 @@ import org.meshtastic.proto.TAKPacket
import org.meshtastic.proto.User
import kotlin.test.Test
import kotlin.test.assertEquals
+import kotlin.test.assertNotNull
import kotlin.test.assertTrue
import kotlin.time.Clock
import kotlin.time.Duration.Companion.minutes
@@ -331,6 +332,42 @@ class TAKMeshIntegrationTest {
assertTrue(h.commandSender.sentPackets.isEmpty())
}
+ // ── Dropped-outcome discrimination (regression: #6583 self-test blind spot) ────────
+
+ @Test
+ fun `v1 drop of an unsupported CoT type is schema-limited`() = runTest(UnconfinedTestDispatcher()) {
+ // a-h-G is a shape/marker type the legacy v1 TAKPacket schema has no field for at all β€”
+ // this is the "permanent limitation" case, not a size problem.
+ val h = TestHarness(nodeRepository = FakeNodeRepository(firmwareVersion = "2.7.0.0"))
+ val marker = CoTMessage(uid = "marker-1", type = "a-h-G", stale = Clock.System.now() + 5.minutes)
+
+ val outcome = h.integration.sendCoTToMeshForTest(marker, forceV2 = false)
+
+ val dropped = assertNotNull(outcome as? TakSendOutcome.Dropped, "expected a Dropped outcome, got $outcome")
+ assertTrue(dropped.schemaLimited, "an unsupported CoT type must be reported as schema-limited")
+ }
+
+ @Test
+ fun `v1 drop of an oversize but schema-representable PLI is NOT schema-limited`() =
+ runTest(UnconfinedTestDispatcher()) {
+ // a-f-G (PLI) IS representable in the v1 schema β€” this must be dropped for size, not
+ // mislabeled as an expected schema gap. This is the exact blind spot the self-test's
+ // expectedDrop flag has to avoid: an MTU problem hiding behind "expected" v1 behavior.
+ val h = TestHarness(nodeRepository = FakeNodeRepository(firmwareVersion = "2.7.0.0"))
+ val oversizePli =
+ CoTMessage(
+ uid = "pli-1",
+ type = "a-f-G-U-C",
+ stale = Clock.System.now() + 5.minutes,
+ contact = CoTContact(callsign = "X".repeat(500)),
+ )
+
+ val outcome = h.integration.sendCoTToMeshForTest(oversizePli, forceV2 = false)
+
+ val dropped = assertNotNull(outcome as? TakSendOutcome.Dropped, "expected a Dropped outcome, got $outcome")
+ assertTrue(!dropped.schemaLimited, "an oversize drop of a representable type must not be schema-limited")
+ }
+
// ── GeoChat callsign enrichment ──────────────────────────────────────────
@Test

diff --git a/core/takserver/src/commonTest/kotlin/org/meshtastic/core/takserver/TakMeshTestRunnerTest.kt b/core/takserver/src/commonTest/kotlin/org/meshtastic/core/takserver/TakMeshTestRunnerTest.kt
new file mode 100644
index 0000000000..1be745fe4f
--- /dev/null
+++ b/core/takserver/src/commonTest/kotlin/org/meshtastic/core/takserver/TakMeshTestRunnerTest.kt
@@ -0,0 +1,180 @@
+/*
+ * Copyright (c) 2026 Meshtastic LLC
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see <https://www.gnu.org/licenses/>.
+ */
+package org.meshtastic.core.takserver
+
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.test.UnconfinedTestDispatcher
+import kotlinx.coroutines.test.runTest
+import org.meshtastic.core.di.CoroutineDispatchers
+import org.meshtastic.core.repository.MeshConfigHandler
+import org.meshtastic.core.repository.RadioSessionContext
+import org.meshtastic.core.testing.FakeCommandSender
+import org.meshtastic.core.testing.FakeNodeRepository
+import org.meshtastic.core.testing.FakeServiceRepository
+import org.meshtastic.core.testing.FakeTakPrefs
+import org.meshtastic.core.testing.TestDataFactory
+import org.meshtastic.proto.Channel
+import org.meshtastic.proto.Config
+import org.meshtastic.proto.DeviceUIConfig
+import org.meshtastic.proto.LoRaRegionPresetMap
+import org.meshtastic.proto.LocalConfig
+import org.meshtastic.proto.LocalModuleConfig
+import org.meshtastic.proto.ModuleConfig
+import org.meshtastic.proto.PortNum
+import kotlin.test.Test
+import kotlin.test.assertEquals
+import kotlin.test.assertTrue
+
+/**
+ * Regression coverage for the self-test blind spot found while investigating #6583: [TakMeshTestRunner] must exercise
+ * the SAME real dispatch pipeline ([TAKMeshIntegration.sendCoTToMeshV1] / `sendCoTToMeshV2`) that live TAK traffic
+ * uses, not a hardcoded v2-only shortcut β€” and it must do so for BOTH protocol versions regardless of whichever
+ * firmware a test radio happens to report, since [TAKMeshIntegration.useTakV2] gates real traffic on the connected
+ * radio's firmware and older firmware silently falls back to the much more limited v1 schema.
+ */
+class TakMeshTestRunnerTest {
+
+ private class FakeMeshConfigHandler : MeshConfigHandler {
+ override val localConfig = MutableStateFlow(LocalConfig())
+ override val moduleConfig = MutableStateFlow(LocalModuleConfig())
+
+ override fun handleDeviceConfig(config: Config, session: RadioSessionContext) = true
+
+ override fun handleModuleConfig(config: ModuleConfig, session: RadioSessionContext) = true
+
+ override fun handleChannel(channel: Channel, session: RadioSessionContext) = true
+
+ override fun handleDeviceUIConfig(config: DeviceUIConfig, session: RadioSessionContext) = true
+
+ override fun handleRegionPresets(map: LoRaRegionPresetMap, session: RadioSessionContext) = true
+ }
+
+ private class Harness(firmwareVersion: String?) {
+ val serverManager = FakeTAKServerManager()
+ val commandSender = FakeCommandSender()
+ val serviceRepository = FakeServiceRepository()
+ val meshConfigHandler = FakeMeshConfigHandler()
+ val nodeRepository =
+ FakeNodeRepository().apply {
+ setMyNodeInfo(TestDataFactory.createMyNodeInfo(firmwareVersion = firmwareVersion))
+ }
+ val takPrefs = FakeTakPrefs()
+ private val dispatcher = UnconfinedTestDispatcher()
+ val broadcaster =
+ MeshToCotBroadcaster(
+ serverManager,
+ nodeRepository,
+ takPrefs,
+ CoroutineDispatchers(io = dispatcher, main = dispatcher, default = dispatcher),
+ )
+
+ val integration =
+ TAKMeshIntegration(
+ takServerManager = serverManager,
+ commandSender = commandSender,
+ serviceRepository = serviceRepository,
+ meshConfigHandler = meshConfigHandler,
+ nodeRepository = nodeRepository,
+ meshToCotBroadcaster = broadcaster,
+ )
+
+ val runner = TakMeshTestRunner(integration)
+ }
+
+ // ── runAll() covers both protocols ──────────────────────────────────────
+
+ @Test
+ fun `runAll exercises every fixture on both v1 and v2`() = runTest(UnconfinedTestDispatcher()) {
+ // Firmware is irrelevant here β€” the runner forces the protocol per pass rather than
+ // reading Capabilities.supportsTakV2, which is exactly the bug this test guards against.
+ val h = Harness(firmwareVersion = null)
+
+ h.runner.runAll()
+
+ val results = h.runner.results.value
+ assertEquals(TakMeshTestRunner.FIXTURE_NAMES.size * 2, results.size)
+ assertEquals(TakMeshTestRunner.FIXTURE_NAMES.size, results.count { it.protocol == TakProtocol.V2 })
+ assertEquals(TakMeshTestRunner.FIXTURE_NAMES.size, results.count { it.protocol == TakProtocol.V1 })
+ }
+
+ @Test
+ fun `v2 pass sends on ATAK_PLUGIN_V2 regardless of connected firmware`() = runTest(UnconfinedTestDispatcher()) {
+ // Connected "radio" reports legacy firmware β€” if the runner still read useTakV2() instead of
+ // forcing the protocol, this would incorrectly downgrade the v2 pass to v1.
+ val h = Harness(firmwareVersion = "2.7.0.0")
+
+ h.runner.runAll()
+
+ val v2Results = h.runner.results.value.filter { it.protocol == TakProtocol.V2 }
+ val v2Sent = h.commandSender.sentPackets.count { it.dataType == PortNum.ATAK_PLUGIN_V2.value }
+ assertEquals(v2Results.count { it.passed }, v2Sent, "every passing v2 result must correspond to a v2 send")
+ assertTrue(v2Sent > 0, "at least one fixture must round-trip through the real v2 pipeline")
+ }
+
+ @Test
+ fun `v1 pass sends on legacy ATAK_PLUGIN regardless of connected firmware`() = runTest(UnconfinedTestDispatcher()) {
+ // Connected "radio" reports v2-capable firmware β€” if the runner still read useTakV2() instead
+ // of forcing the protocol, this would incorrectly upgrade the v1 pass to v2, hiding the exact
+ // blind spot reported in #6583.
+ val h = Harness(firmwareVersion = "2.8.0.0")
+
+ h.runner.runAll()
+
+ val v1Results = h.runner.results.value.filter { it.protocol == TakProtocol.V1 }
+ val v1Sent = h.commandSender.sentPackets.count { it.dataType == PortNum.ATAK_PLUGIN.value }
+ assertEquals(v1Results.count { it.passed }, v1Sent, "every passing v1 result must correspond to a v1 send")
+ assertTrue(v1Sent > 0, "PLI/GeoChat fixtures must still round-trip through the real v1 pipeline")
+ }
+
+ @Test
+ fun `v1 pass reports non-PLI non-chat drops as expected not as failures`() = runTest(UnconfinedTestDispatcher()) {
+ val h = Harness(firmwareVersion = null)
+
+ h.runner.runAll()
+
+ val v1Results = h.runner.results.value.filter { it.protocol == TakProtocol.V1 }
+ // marker_spot.xml is type "b-m-p-s-m" β€” not representable in the legacy v1 TAKPacket
+ // schema (only PLI/"a-f-*" and chat/"b-t-f" are). This must show up as an *expected*
+ // drop, not an unlabeled self-test failure, per the v1 schema's documented limitations.
+ val markerResult = v1Results.single { it.fixtureName == "marker_spot.xml" }
+ assertTrue(markerResult.expectedDrop, "v1 dropping an unsupported CoT type is expected, not a bug")
+ assertTrue(!markerResult.passed)
+
+ // Every v1 drop must be accounted for: either the known schema-coverage limitation
+ // (expectedDrop) or an oversize drop, which is a real MTU problem and correctly NOT
+ // labeled expected even though it happened on v1 β€” see TakSendOutcome.Dropped.schemaLimited.
+ // A drop that is neither would be a genuine, unaccounted-for failure.
+ assertTrue(
+ v1Results.filter { !it.passed }.all { it.expectedDrop || it.error?.startsWith("Oversized") == true },
+ "every v1 drop in a clean run should be the known schema limitation or an oversize drop, " +
+ "not an unaccounted-for failure",
+ )
+ }
+
+ @Test
+ fun `v2 pass has no expected drops - v2 must support every fixture type`() = runTest(UnconfinedTestDispatcher()) {
+ val h = Harness(firmwareVersion = null)
+
+ h.runner.runAll()
+
+ val v2Results = h.runner.results.value.filter { it.protocol == TakProtocol.V2 }
+ assertTrue(
+ v2Results.none { it.expectedDrop },
+ "v2 has no known schema gaps, so nothing should be labeled expected",
+ )
+ }
+}

diff --git a/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/radio/component/TAKConfigItemList.kt b/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/radio/component/TAKConfigItemList.kt
index cc18c69a0e..115fb55c04 100644
--- a/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/radio/component/TAKConfigItemList.kt
+++ b/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/radio/component/TAKConfigItemList.kt
@@ -47,13 +47,13 @@ import androidx.compose.ui.graphics.Color
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import kotlinx.coroutines.launch
+import org.jetbrains.compose.resources.StringResource
import org.jetbrains.compose.resources.pluralStringResource
import org.jetbrains.compose.resources.stringResource
import org.koin.compose.koinInject
import org.meshtastic.core.common.BuildConfigProvider
import org.meshtastic.core.model.getColorFrom
import org.meshtastic.core.model.getStringResFrom
-import org.meshtastic.core.repository.CommandSender
import org.meshtastic.core.repository.TakPrefs
import org.meshtastic.core.resources.Res
import org.meshtastic.core.resources.back
@@ -79,15 +79,20 @@ import org.meshtastic.core.resources.tak_server_status_unavailable
import org.meshtastic.core.resources.tak_server_status_waiting
import org.meshtastic.core.resources.tak_server_test_card_title
import org.meshtastic.core.resources.tak_server_test_idle
+import org.meshtastic.core.resources.tak_server_test_protocol_v1_label
+import org.meshtastic.core.resources.tak_server_test_protocol_v2_label
import org.meshtastic.core.resources.tak_server_test_result_bytes
+import org.meshtastic.core.resources.tak_server_test_result_expected_drop
import org.meshtastic.core.resources.tak_server_test_result_unknown_error
-import org.meshtastic.core.resources.tak_server_test_results
+import org.meshtastic.core.resources.tak_server_test_results_v2
import org.meshtastic.core.resources.tak_server_test_run
import org.meshtastic.core.resources.tak_server_test_running
import org.meshtastic.core.resources.tak_team
import org.meshtastic.core.takserver.TAKDataPackageGenerator
+import org.meshtastic.core.takserver.TAKMeshIntegration
import org.meshtastic.core.takserver.TAKServerManager
import org.meshtastic.core.takserver.TakMeshTestRunner
+import org.meshtastic.core.takserver.TakProtocol
import org.meshtastic.core.takserver.TakTestResult
import org.meshtastic.core.ui.component.DropDownPreference
import org.meshtastic.core.ui.component.SwitchPreference
@@ -392,8 +397,8 @@ private fun TakMeshTestCard() {
val buildConfig: BuildConfigProvider = koinInject()
if (!buildConfig.isDebug) return
- val commandSender: CommandSender = koinInject()
- val testRunner = remember { TakMeshTestRunner(commandSender) }
+ val takMeshIntegration: TAKMeshIntegration = koinInject()
+ val testRunner = remember { TakMeshTestRunner(takMeshIntegration) }
val results by testRunner.results.collectAsStateWithLifecycle()
val isRunning by testRunner.isRunning.collectAsStateWithLifecycle()
val currentFixture by testRunner.currentFixture.collectAsStateWithLifecycle()
@@ -418,8 +423,8 @@ internal fun TakMeshTestCardContent(
onRunTests: () -> Unit,
) {
val loadingLabel = stringResource(Res.string.tak_server_loading)
- val passed = results.count { it.passed }
- val failed = results.count { !it.passed }
+ val v2Results = results.filter { it.protocol == TakProtocol.V2 }
+ val v1Results = results.filter { it.protocol == TakProtocol.V1 }
TitledCard(title = stringResource(Res.string.tak_server_test_card_title)) {
Row(
@@ -437,25 +442,6 @@ internal fun TakMeshTestCardContent(
},
style = MaterialTheme.typography.bodyLarge,
)
- if (results.isNotEmpty()) {
- Text(
- text =
- stringResource(
- Res.string.tak_server_test_results,
- passed,
- failed,
- results.size,
- fixtureCount,
- ),
- style = MaterialTheme.typography.bodySmall,
- color =
- if (failed > 0) {
- MaterialTheme.colorScheme.error
- } else {
- MaterialTheme.colorScheme.onSurfaceVariant
- },
- )
- }
}
if (isRunning) {
CircularProgressIndicator()
@@ -467,7 +453,58 @@ internal fun TakMeshTestCardContent(
}
}
- // Results list
+ if (v2Results.isNotEmpty()) {
+ TakProtocolResultsSection(
+ titleRes = Res.string.tak_server_test_protocol_v2_label,
+ results = v2Results,
+ fixtureCount = fixtureCount,
+ )
+ }
+ if (v1Results.isNotEmpty()) {
+ TakProtocolResultsSection(
+ titleRes = Res.string.tak_server_test_protocol_v1_label,
+ results = v1Results,
+ fixtureCount = fixtureCount,
+ )
+ }
+ }
+}
+
+/**
+ * One protocol's fixture run within the TAK self-test card: a summary line (passed / expected-limitation drops /
+ * unexpected failures) followed by the per-fixture rows. Expected v1 schema drops are surfaced distinctly from real
+ * failures so a low v1 pass rate reads as intentional legacy-schema behavior, not a self-test regression.
+ */
+@Composable
+private fun TakProtocolResultsSection(titleRes: StringResource, results: List<TakTestResult>, fixtureCount: Int) {
+ val passed = results.count { it.passed }
+ val expectedDrops = results.count { !it.passed && it.expectedDrop }
+ val unexpectedFailed = results.count { !it.passed && !it.expectedDrop }
+
+ Column {
+ HorizontalDivider()
+ Column(modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 8.dp)) {
+ Text(text = stringResource(titleRes), style = MaterialTheme.typography.titleSmall)
+ Text(
+ text =
+ stringResource(
+ Res.string.tak_server_test_results_v2,
+ passed,
+ expectedDrops,
+ unexpectedFailed,
+ results.size,
+ fixtureCount,
+ ),
+ style = MaterialTheme.typography.bodySmall,
+ color =
+ if (unexpectedFailed > 0) {
+ MaterialTheme.colorScheme.error
+ } else {
+ MaterialTheme.colorScheme.onSurfaceVariant
+ },
+ )
+ }
+
for (result in results) {
HorizontalDivider()
Row(
@@ -482,13 +519,21 @@ internal fun TakMeshTestCardContent(
)
Text(
text =
- if (result.passed) {
- stringResource(Res.string.tak_server_test_result_bytes, result.compressedBytes)
- } else {
- result.error ?: stringResource(Res.string.tak_server_test_result_unknown_error)
+ when {
+ result.passed ->
+ stringResource(Res.string.tak_server_test_result_bytes, result.compressedBytes)
+
+ result.expectedDrop -> stringResource(Res.string.tak_server_test_result_expected_drop)
+
+ else -> result.error ?: stringResource(Res.string.tak_server_test_result_unknown_error)
},
style = MaterialTheme.typography.bodySmall,
- color = if (result.passed) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.error,
+ color =
+ when {
+ result.passed -> MaterialTheme.colorScheme.primary
+ result.expectedDrop -> MaterialTheme.colorScheme.onSurfaceVariant
+ else -> MaterialTheme.colorScheme.error
+ },
)
}
}

diff --git a/screenshot-tests/src/screenshotTestDebug/reference/org/meshtastic/screenshots/feature/SettingsScreenshotTestsKt/ScreenshotTakTestCardResults_Dark_d19fbf1f_0.png b/screenshot-tests/src/screenshotTestDebug/reference/org/meshtastic/screenshots/feature/SettingsScreenshotTestsKt/ScreenshotTakTestCardResults_Dark_d19fbf1f_0.png
index 124c1956ab..9544204f35 100644
Binary files a/screenshot-tests/src/screenshotTestDebug/reference/org/meshtastic/screenshots/feature/SettingsScreenshotTestsKt/ScreenshotTakTestCardResults_Dark_d19fbf1f_0.png and b/screenshot-tests/src/screenshotTestDebug/reference/org/meshtastic/screenshots/feature/SettingsScreenshotTestsKt/ScreenshotTakTestCardResults_Dark_d19fbf1f_0.png differ

diff --git a/screenshot-tests/src/screenshotTestDebug/reference/org/meshtastic/screenshots/feature/SettingsScreenshotTestsKt/ScreenshotTakTestCardResults_Light_b29dc7a7_0.png b/screenshot-tests/src/screenshotTestDebug/reference/org/meshtastic/screenshots/feature/SettingsScreenshotTestsKt/ScreenshotTakTestCardResults_Light_b29dc7a7_0.png
index 8ac5d46040..0c253506ae 100644
Binary files a/screenshot-tests/src/screenshotTestDebug/reference/org/meshtastic/screenshots/feature/SettingsScreenshotTestsKt/ScreenshotTakTestCardResults_Light_b29dc7a7_0.png and b/screenshot-tests/src/screenshotTestDebug/reference/org/meshtastic/screenshots/feature/SettingsScreenshotTestsKt/ScreenshotTakTestCardResults_Light_b29dc7a7_0.png differ

diff --git a/screenshot-tests/src/screenshotTestDebug/reference/org/meshtastic/screenshots/feature/SettingsScreenshotTestsKt/ScreenshotTakTestCardRunning_Dark_d19fbf1f_0.png b/screenshot-tests/src/screenshotTestDebug/reference/org/meshtastic/screenshots/feature/SettingsScreenshotTestsKt/ScreenshotTakTestCardRunning_Dark_d19fbf1f_0.png
index 06115f878d..3b36fd017a 100644
Binary files a/screenshot-tests/src/screenshotTestDebug/reference/org/meshtastic/screenshots/feature/SettingsScreenshotTestsKt/ScreenshotTakTestCardRunning_Dark_d19fbf1f_0.png and b/screenshot-tests/src/screenshotTestDebug/reference/org/meshtastic/screenshots/feature/SettingsScreenshotTestsKt/ScreenshotTakTestCardRunning_Dark_d19fbf1f_0.png differ

diff --git a/screenshot-tests/src/screenshotTestDebug/reference/org/meshtastic/screenshots/feature/SettingsScreenshotTestsKt/ScreenshotTakTestCardRunning_Light_b29dc7a7_0.png b/screenshot-tests/src/screenshotTestDebug/reference/org/meshtastic/screenshots/feature/SettingsScreenshotTestsKt/ScreenshotTakTestCardRunning_Light_b29dc7a7_0.png
index 113b8c49e3..72ceeefa07 100644
Binary files a/screenshot-tests/src/screenshotTestDebug/reference/org/meshtastic/screenshots/feature/SettingsScreenshotTestsKt/ScreenshotTakTestCardRunning_Light_b29dc7a7_0.png and b/screenshot-tests/src/screenshotTestDebug/reference/org/meshtastic/screenshots/feature/SettingsScreenshotTestsKt/ScreenshotTakTestCardRunning_Light_b29dc7a7_0.png differ

Served by rngit 1.5.0 - Generated in 0.15s